Skip to content

Replace the _simple_case_mappings map with a block table - #25

Merged
yhirose merged 3 commits into
masterfrom
blocktable-case-mapping
Aug 27, 2026
Merged

yhirose merged 3 commits into
masterfrom
blocktable-case-mapping

Conversation

@yhirose

@yhirose yhirose commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

Replaces the four code-point-indexed case tables (_simple_case_mappings, _special_case_mappings, _special_case_mappings_default, _case_foldings) with block tables instead of the sorted arrays + std::lower_bound that master switched to. _normalization_composition stays a sorted array since its key is a code point pair, not a single code point, so it can't be block-tabled the same way; a spot check found no measurable difference there.

Motivation

Each of these four tables is looked up once per character, so std::lower_bound shows up directly in to_uppercase(), to_case_fold(), and friends. A block table keeps the static-data benefit master's commit was going for (no map construction at start-up) and is faster than binary search.

Layout

Same shape master already uses for every other property table: a uint16_t index per code point through a block table, plus a flat value array. Row 0 of the value array is a "no entry" sentinel, so the index doubles as the found/not-found flag.

_special_case_mappings has several code points with more than one entry (full_case_mapping() walks them in SpecialCasing.txt order and applies the first whose language/context condition matches), so it's grouped as {offset, count} into a flat SpecialCasing array, the same shape as the existing script extension tables, keyed by code point through a block table. _special_case_mappings_default and _case_foldings have one entry per code point, so they follow _simple_case_mappings's shape directly.

_find_code_point() and the SpecialCaseMapping/CaseFoldingEntry wrapper structs it operated on are no longer used by anything and are removed.

Measurements

Apple M-series, Release -O2, clang, 20 iterations, median of 3 runs:

master (sorted array) this PR (block table)
to_uppercase(), ASCII, 500k chars 319.5 ms 52.6 ms
to_uppercase(), mixed scripts, 500k chars 306.2 ms 51.7 ms
to_case_fold(), ASCII, 500k chars 149.1 ms 32.4 ms
to_case_fold(), mixed scripts, 500k chars 178.5 ms 34.3 ms
to_nfc() (unaffected, sorted array kept) 4390.8 ms 4363.8 ms

Isolating just the two special-casing searches accounted for about 75% of to_uppercase()'s time on master despite _special_case_mappings/_special_case_mappings_default having only 16 and 103 entries respectively; a handful of dependent loads per character adds up.

Static data: startup allocations stay at 2933 (unchanged from master, since master already removed the map construction). Test binary is about 17 KB larger than master's sorted-array version, in exchange for the above.

Correctness

Exhaustive differential test against the pre-sorted-array commit (bfdd356, the unordered_map/unordered_multimap version): simple_case_mapping(), simple_case_folding(), is_script(), and full_case_mapping() (upper/lower/title/fold, across 6 locales and 7 surrounding contexts exercising every SpecialCasingContext condition) return identical results for all 1114112 code points. Full test suite passes (770963 assertions in 59 test cases), clean with -Wall -Wextra -Werror on clang.

Changes to generateTable()

Two additions, both defaulting to the previous behaviour so every other generated table comes out byte for byte identical:

  • an optional blockSize override. findBestBlockSize() hardcodes an 8-byte item size, so it picks 64 for a uint16_t index table where 256 is smaller.
  • tables that do not span the whole code point space are padded to a whole number of blocks, and get_value() gets a bounds check against the table length instead of 0x10FFFF.

While looking at this I also noticed that findDataSize() ignores the cost of the _blocks[] pointer array, so findBestBlockSize() systematically picks blocks that are too small for the existing tables as well. That's unrelated to case mapping and left out of this PR.

@samhocevar

Copy link
Copy Markdown
Contributor

Yes, leveraging generateTable looks like a much better approach. Any improvements in that function will benefit the whole library.

simple_case_mapping() searched a sorted array of 2989 entries with
std::lower_bound, which is about 12 dependent loads per lookup. It is
called once per character, so it dominates to_uppercase() and friends.

Emit the table the way every other property table is already emitted
instead: a uint16_t index per code point (block size 256, 27 of 490
blocks materialised) plus a flat char32_t[3] value array. Row 0 is a
"no mapping" sentinel, so the index doubles as the found/not-found
flag and no separate presence check is needed. The index table stops
after the last mapped code point (U+1E943) rather than covering the
whole code point space.

generateTable() gains two things to support this, both defaulting to
the previous behaviour so every other generated table comes out byte
for byte identical:

- an optional blockSize override. findBestBlockSize() hardcodes an
  8-byte item size, so it picks 64 for this uint16_t index table where
  256 is about 10 kB smaller.
- tables that do not span the whole code point space are padded to a
  whole number of blocks, and get_value() gets a bounds check against
  the table length instead of 0x10FFFF.

to_uppercase() over a 500k character string, 20 iterations, Apple
M-series, clang -O2, median of 3 runs:

                    sorted array   block table
    ASCII              318.7 ms      153.9 ms
    mixed scripts      305.1 ms      134.8 ms

simple_case_mapping() returns identical results for all 1114112 code
points and all three mapping types.
@yhirose
yhirose force-pushed the blocktable-case-mapping branch from 60be497 to ac6f0d8 Compare August 26, 2026 21:18
full_case_mapping() searches _special_case_mappings and
_special_case_mappings_default with std::lower_bound for every
character, same as the simple case mapping table did before the
previous commit. case_folding()/simple_case_folding() do the same
against _case_foldings. Convert all three to block tables for the
same reason: they are looked up once per character, so std::lower_bound
dominates to_uppercase()/to_case_fold() even though the tables
themselves are small.

_special_case_mappings has several code points with more than one
entry (full_case_mapping() walks them in SpecialCasing.txt order and
applies the first whose language/context condition matches), so it
is grouped as {offset, count} into a flat SpecialCasing array, the
same shape as the existing script extension tables, keyed by code
point through a block table. _special_case_mappings_default and
_case_foldings have one entry per code point, so they follow the
simple case mapping table's shape directly: a block table index plus
a flat value array with row 0 as a "no entry" sentinel.

_find_code_point() and the SpecialCaseMapping/CaseFoldingEntry
wrapper structs it operated on are no longer used by anything and are
removed.

to_uppercase() over a 500k character string, 20 iterations, Apple
M-series, clang -O2, median of 3 runs:

                    master (sorted array)   this commit
    ASCII                 319.5 ms             52.6 ms
    mixed scripts         306.2 ms             51.7 ms

Isolating just the two special-casing searches previously accounted
for 118 of the 155 ms this branch spent per to_uppercase() call
before this commit; that's gone now.

simple_case_mapping(), simple_case_folding(), full_case_mapping()
(all four locales/contexts x code points), and normalization all
return identical results to master before the sorted-array commit,
checked exhaustively over all 1114112 code points. Full test suite
passes (770963 assertions in 59 test cases), clean with
-Wall -Wextra -Werror on clang.
- scripts/gen_tables.py: extract the "grow indices to cp+1, stamp
  index i" two-line idiom (used once by the pre-existing
  _simple_case_mappings table and three more times by the tables added
  in the previous commit) into a small assign_index() helper, and
  point each blockSize=256 override at the rationale already written
  above _simple_case_mappings instead of leaving it unexplained.
- unicodelib.h: simple_case_folding() and case_folding() had grown an
  identical "look up the block-table index, deref into
  _case_folding_values" preamble. Factor it into find_case_folding(),
  restoring the single lookup point _find_code_point() used to
  provide before the previous commit removed it.
- unicodelib.h: full_case_mapping()'s default-entry branch exceeded
  the file's ~80-column width; name the looked-up SpecialCasing
  locally instead of indexing inline, matching the pattern already
  used a few lines above for the context-dependent branch.

No behavioral change: full test suite passes (770963 assertions in 59
test cases), clean with -Wall -Wextra -Werror, and
simple_case_mapping()/simple_case_folding()/full_case_mapping()/
normalization all still match the pre-refactor unordered_map
implementation exhaustively over all 1114112 code points.
@yhirose
yhirose merged commit 990dbc4 into master Aug 27, 2026
6 checks passed
yhirose added a commit that referenced this pull request Aug 28, 2026
Every table was emitted as one pointer per block, one value per block,
and the non-uniform blocks written out in full with no deduplication:
3,014,400 bytes of fixed overhead across the seventeen tables before any
of the values themselves. findBestBlockSize() also priced every table's
entries at 8 bytes, so the size it picked was not the smallest one for a
4-byte enum or a 2-byte index.

Emit each table as a two-stage trie instead: a first stage mapping a
block of code points to a block of the second stage, a second stage with
one entry per code point, and, where a value is wider than an index into
the distinct values, a pool holding each distinct value once. Blocks with
equal contents share one second-stage block, which is where the
compression comes from. The block size is searched per table over shifts
5..9, priced with the table's real entry size.

The four case mapping tables keep the 256 that #25 picked by hand, now
for a measured reason rather than the 8-byte assumption: the search
minimises bytes and picks 32 or 64 for them, which is 10% slower on
to_lowercase/to_uppercase. Those read all four tables per character, so
the four first stages compete for cache, and a first stage is dense and
touched on every lookup while a second stage is sparse -- none of which
byte count can see. Pinning costs 9,246 bytes. The property tables are
the opposite case: pinning 256 on them costs 39,040 bytes and changes no
measurement.

Nothing about the tables' contents changes. Every one of the 1,114,112
code points was dumped through all thirteen property tables and through
the case mapping and folding accessors, before and after, and the two
dumps are byte for byte identical.

Bytes a program links, over an empty program that includes the header
(g++ -O2, --gc-sections, stripped):

                       before       after
  is_alphabetic       237,712      45,128
  general_category    237,712      45,128
  grapheme_length     536,864      82,128
  to_nfkd           1,085,816     385,400
  to_lowercase      1,769,848     541,048

Throughput over a 200k character string, best of 7 (g++ -O2):

                       before       after
  general_category     0.36 ms     0.10 ms
  is_alphabetic        0.35 ms     0.11 ms
  combining_class      0.68 ms     0.11 ms
  to_nfkd              2.51 ms     1.25 ms
  to_nfc              21.28 ms    18.83 ms
  to_lowercase         2.61 ms     2.54 ms

A sorted range table with binary search was tried first and rejected. It
is smaller still (362,132 bytes for the property tables) but 9.4x slower
on general_category, 10.5x on is_alphabetic and 2.9x on to_nfkd, and a
block index over the ranges does not rescue it: the ranges are not spread
evenly, and U+0000..U+0FFF alone holds 1,169 general category ranges and
1,241 derived core property ranges, so the search does not get shorter
where real text lives. Making the index fine enough to help would cost
more than the ranges save.
@yhirose
yhirose deleted the blocktable-case-mapping branch October 9, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants